Skip to content

Repository files navigation

CVEonDeserializationFinder

Runtime detection of .NET deserialization RCE (CVE-2025-53770 “ToolShell” and the wider ysoserial.net gadget family) via two independent channels:

  1. AMSI provider (primary) — a native x64 COM DLL registered under HKLM\SOFTWARE\Microsoft\AMSI\Providers. Every Assembly.Load(byte[]) in an AMSI-instrumented host (w3wp.exe, PowerShell 5.1, Office VBA, iisexpress.exe) reaches our IAntimalwareProvider::Scan, we parse the incoming PE metadata with IMetaDataDispenserEx::OpenScopeOnMemory, and match TypeRef/AssemblyRef/TypeDef/TypeSpec against a JSON rulebase mechanically derived from ysoserial.net. Hits are appended to %ProgramData%\CVEonDeserializationFinder\hits.log and the scanned buffer is dumped to a per-Scan subfolder for offline triage.
  2. CLR profiler (secondary) — an in-process ICorProfilerCallback3 profiler that reads module metadata via IMetaDataImport2 to cover the one blind spot AMSI cannot see: XmlSerializer / Reflection.Emit dynamic assemblies (Microsoft.GeneratedCode) have no PE image and never reach the CLR→AMSI hook. It reuses the same controller, named pipe and rulebase as the AMSI channel, forwarding a metadata-record read on ClassLoadFinished — where the gadget TypeRefs (ObjectDataProvider, LosFormatter, ExpandedWrapper) first become readable on the dynamic serializer module. It is wired end-to-end (SPIKE-A → P6) and driven by a one-command launcher; see the CLR profiler channel section below and docs/profiler-integration-plan.md.

Solution layout

src/
  CVEonDeserializationFinder.AmsiProvider/          native x64 COM DLL, C++17
     dllmain.cpp                                    exports + CLSID registration
     provider.{h,cpp}                               IAntimalwareProvider::Scan
     metadata_scanner.{h,cpp}                       ECMA-335 metadata reader
     rules.{h,cpp}                                  JSON matcher
     config.{h,cpp}                                 registry-driven configuration
     logger.{h,cpp}                                 JSON-line hits.log + per-Scan dumps
     CVEonDeserializationFinder.AmsiProvider.def    exports
     third_party/json.hpp                           nlohmann/json 3.11.3 header
  CVEonDeserializationFinder.AmsiController/        .NET 4.8 CLI ("AV engine" + installer)
     Program.cs                                     Main + Spectre.Console verb dispatch
     Commands/                                      one class per verb (install-amsi, listen,
                                                    install-profiler, uninstall-profiler, ...)
     Analysis/                                      PipeProtocol, MetadataScanner, RulesEngine,
                                                    HitsLogWriter, ConfigKeys
  CVEonDeserializationFinder.NativeProfiler/        native x64 ICorProfilerCallback3, C++17
     SpikeProfiler.cpp                              ClassLoadFinished metadata probe (dynamic modules)
     pipe_client.{h,cpp}                            v3 metadata-record serializer + named-pipe client
     ProfilerCallbackStubs.inl                      generated S_OK callback stubs
     CVEonDeserializationFinder.NativeProfiler.def  exports
  CVEonDeserializationFinder.Tester/                .NET 4.8 harness
     CVEonDeserializationFinder.Tester.cs           --bf / --xml / --los / --load / --hold
  CVEonDeserializationFinder.PayloadGenerator/      .NET 4.8 CLI
     CVEonDeserializationFinder.PayloadGenerator.cs benign + malicious verbs
     Benign/                                        4 safe generators (one per sink)
     Malicious/YsoserialWrapper.cs                  ysoserial.exe wrapper
scripts/                                             install/uninstall/debug/trigger + profiler launcher PS1
docs/                                                CFP-EN.md, CFP-RU.md, profiler-integration-plan.md
rules/gadgets.json                                   32 rules, staged into ProgramData on install
CFPs/                                                offline CFP snapshots (Sessionize)
third_party/ysoserial.net/                           git submodule

The companion repo ../WebApplication/ hosts the intentionally vulnerable ASP.NET Web Forms lab that the AMSI provider protects during validation.

Build

Full x64 build from a Developer PowerShell (Visual Studio 2022, Build Tools 18 or Pro/Enterprise):

$msbuild = 'C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\amd64\MSBuild.exe'

# One-time restore for the ysoserial.net submodule (malicious generator wrapper).
& $msbuild third_party\ysoserial.net\ysoserial.sln -t:Restore -p:RestorePackagesConfig=true

# Full solution build.
& $msbuild CVEonDeserializationFinder.sln -p:Configuration=Release -p:Platform=x64

Outputs of interest:

Artifact Path
AMSI provider DLL (~175 KB) src\CVEonDeserializationFinder.AmsiProvider\bin\x64\Release\CVEonDeserializationFinder.AmsiProvider.dll
Controller CLI src\CVEonDeserializationFinder.AmsiController\bin\x64\Release\CVEonDeserializationFinder.AmsiController.exe
Native profiler (SPIKE-A) src\CVEonDeserializationFinder.NativeProfiler\bin\x64\Release\CVEonDeserializationFinder.NativeProfiler.dll
Tester harness src\CVEonDeserializationFinder.Tester\bin\Debug\CVEonDeserializationFinder.Tester.exe
Payload generator src\CVEonDeserializationFinder.PayloadGenerator\bin\Debug\CVEonDeserializationFinder.PayloadGenerator.exe
ysoserial.exe third_party\ysoserial.net\ysoserial\bin\Release\ysoserial.exe

Install (elevated)

The controller stages a permanent copy of the provider DLL into %ProgramData%\CVEonDeserializationFinder\provider\ and registers COM/AMSI against that path. Rebuilds after the initial install only replace the file on disk — registration stays intact and regsvr32 does not need to run again unless you moved the ProgramData tree.

# One-shot install (right-click "Run as administrator"):
scripts\install-elevated.ps1

# Or manually from an elevated console:
$exe = 'src\CVEonDeserializationFinder.AmsiController\bin\x64\Release\CVEonDeserializationFinder.AmsiController.exe'
& $exe install-amsi                                            # stages DLL + rules and registers
& $exe configure-scope 'w3wp.exe;iisexpress.exe;powershell.exe;CVEonDeserializationFinder.Tester.exe'
& $exe enforce off                                             # log-only mode (default)
& $exe dump on                                                 # per-Scan assembly dump (default)
& $exe info                                                    # show current state

The controller supports the following verbs:

Verb Purpose
install-amsi [--provider <src>] [--rules-dir <src>] Copy DLL + rules to ProgramData, register COM + AMSI keys, seed defaults.
uninstall-amsi [--provider <path>] Unregister COM + AMSI keys. Does not delete logs / dumps.
install-profiler [--pool <NAME>] [--recycle] Deploy + COM-register the CLR profiler; attach it to an IIS pool (COR_*).
uninstall-profiler [--pool <NAME>] [--unregister] Detach the profiler from a pool; --unregister also drops the CLSID.
configure-scope <exe1;exe2;...> Set the process allow-list (semicolon-separated).
enforce <on|off> Toggle AMSI_RESULT_DETECTED emission. Default off.
debug <on|off> Toggle per-Scan diagnostic lines in hits.log.
dump <on|off> Toggle per-Scan assembly capture into dumps\.
info Show current registration + config + filesystem state.
tail-hits [--follow] [--path <file>] Print / tail the JSON-line hits log.
listen [--ui <live|plain>] Run as the AV engine: accept pipe connections, match rules, live UI.

Registry surface

Key Type Purpose
HKLM\SOFTWARE\Classes\CLSID\{C0E3B7D2-...}\InprocServer32 REG_SZ Points at %ProgramData%\CVEonDeserializationFinder\provider\CVEonDeserializationFinder.AmsiProvider.dll
HKLM\SOFTWARE\Microsoft\AMSI\Providers\{C0E3B7D2-...} key Registers the provider with the AMSI subsystem
HKLM\SOFTWARE\CVEonDeserializationFinder\AllowedProcesses REG_SZ Semicolon-separated allow-list, lowercase-compared
HKLM\SOFTWARE\CVEonDeserializationFinder\EnforceMode DWORD 0 = log only, 1 = return AMSI_RESULT_DETECTED
HKLM\SOFTWARE\CVEonDeserializationFinder\DebugScanLog DWORD 0 = quiet, 1 = per-Scan info line in provider.log
HKLM\SOFTWARE\CVEonDeserializationFinder\DumpAssemblies DWORD 0 = off, 1 = save every matched buffer to disk
HKLM\SOFTWARE\CVEonDeserializationFinder\DumpMaxSize DWORD Skip dump if buffer > N bytes (default 64 MiB)
HKLM\SOFTWARE\CVEonDeserializationFinder\DumpFolderBase REG_SZ Root for per-Scan dump subfolders
HKLM\SOFTWARE\CVEonDeserializationFinder\RulesPath REG_SZ Override rulebase path (default is next to the DLL)
HKLM\SOFTWARE\CVEonDeserializationFinder\HitsLogPath REG_SZ Override hits.log path
HKLM\SOFTWARE\CVEonDeserializationFinder\ProviderLogPath REG_SZ Override provider diagnostics log (default provider.log, separate from hits.log)
HKLM\SOFTWARE\CVEonDeserializationFinder\InboundDumpFolder REG_SZ Controller dump-first folder; every buffer received over the pipe is saved here (default inbound\)

Evidence trail

Three artefacts under %ProgramData%\CVEonDeserializationFinder\ capture every scan, correlated by a per-request id that the provider stamps and the controller echoes back:

  • provider.log — provider-side diagnostics (one line per Scan when DebugScanLog=1), kept separate from hits.log. Each line carries req=<id>, the image name, buffer size, and whether the managed-PE forward to the controller returned status=ok.
  • inbound\ — controller dump-first store: every buffer received over the pipe is written to disk before rules run, as <ts>_<pid>_<proc>_<reqid>_<sha16>.bin plus a .json sidecar (requestId, pid, process, size, sha256). This proves byte-for-byte delivery regardless of whether a rule fired.
  • dumps\ — provider-side fallback evidence, written only when the controller pipe is unavailable, so nothing is lost while the listener is down.
provider.log   {"level":"info","msg":"scan req=247699353894913 proc=w3wp.exe size=3072 kind=managed"}
               {"level":"info","msg":"pipe req=247699353894913 status=ok verdict=1 hits=1 ..."}
inbound\        20260717-153307.412_57672_w3wp.exe_247699353894913_e0e6fe31c7db5a93.bin
               20260717-153307.412_57672_w3wp.exe_247699353894913.json
hits.log       {"level":"hit","pid":57672,"process":"w3wp.exe","hits":[{"id":"GADGET-ObjectDataProvider",...}]}

The same requestId (247699353894913 above) appears in all three, so one scan is traceable end to end: provider → controller → verdict.

Payload generation

The PayloadGenerator emits safe base64 (used by the web-app "Load benign example" button) and wraps ysoserial.exe for malicious payload construction inside authorised lab environments:

$pg = 'src\CVEonDeserializationFinder.PayloadGenerator\bin\Debug\CVEonDeserializationFinder.PayloadGenerator.exe'

# Emit one benign payload for each sink to stdout (labelled).
& $pg demo

# Emit one benign payload for a specific sink.
& $pg benign --sink bf   --output -                # BinaryFormatter, base64 to stdout
& $pg benign --sink xml  --output benign_xml.b64   # XmlSerializer(ToolShell) → file
& $pg benign --sink los  --output benign_los.b64
& $pg benign --sink osf  --output benign_osf.b64

# Malicious wrapper (authorised lab only): builds a ysoserial payload with ping ya.ru as the RCE
# proof-of-life, base64 stdout.
& $pg malicious --sink osf --cmd "ping ya.ru -n 10" --output payload_osf.b64

The malicious wrapper maps sink codes to ysoserial -g / -f:

Sink Gadget Formatter
bf TypeConfuseDelegate BinaryFormatter
xml ObjectDataProvider Xaml
los ActivitySurrogateSelector LosFormatter
osf ObjectDataProvider ObjectStateFormatter

Pre-built benign base64 examples for direct paste into the WebApplication “Load benign example” button live in docs/benign-payloads.md and are regenerated by PayloadGenerator demo.

Web-app validation lab

The ../WebApplication repo hosts a two-tab ASP.NET Web Forms page bound to http://localhost:8088 (port 8080 is reserved on this workstation by gontlm-proxy). Tab 2 (“Deserialization Lab”) exposes the four sinks, a payload textbox, a Load benign example button, a Deserialize button, and a child-process table that highlights ping.exe, cmd.exe, powershell.exe, mshta.exe, etc. spawned by the current w3wp.exe — the canonical proof of successful exploitation.

Bring the site up (elevated):

scripts\install-iis.ps1            # enable full IIS with ASP.NET 4.5
scripts\configure-iis-site.ps1     # create AppPool + site on http://localhost:8088

CLR profiler channel (dynamic-assembly coverage)

The AMSI channel only ever sees a managed PE — every Assembly.Load(byte[]) carries a real PE image the CLR hands to IAntimalwareProvider::Scan. A real XmlSerializer / Reflection.Emit serializer, however, is materialised as a dynamic assembly (Microsoft.GeneratedCode) with no PE image, so it never reaches the CLR→AMSI hook. The profiler closes exactly that gap: it attaches to the CLR inside w3wp.exe, reads the dynamic module's metadata via IMetaDataImport2 on ClassLoadFinished, and forwards a v3 metadata-record to the same controller, named pipe and rulebase as the AMSI channel. Hits it produces carry "source":"profiler" (plus "dynamic":true and the module name) so the two channels stay distinguishable in hits.log.

One-command launcher (elevated). start-profiler-detection.ps1 self-elevates and brings the whole channel up — it does not touch the AMSI provider:

powershell -ExecutionPolicy Bypass -File scripts\start-profiler-detection.ps1
#   optional:  -Pool <name>   -Port <n>   -Rebuild   -IisReset

It (1) starts the controller in a visible live-UI window (listen --ui live), (2) runs install-profiler --pool CVEDeserializationLab (stage the DLL into ProgramData, COM-register the profiler CLSID, set the per-pool COR_* env vars), and (3) recycles the pool so a fresh w3wp regenerates its serializer assemblies under the profiler. Trigger a dynamic serializer path and watch the controller window / hits.log:

Invoke-WebRequest http://localhost:8088/api/deserialize.ashx -Method POST -UseBasicParsing `
  -ContentType 'application/x-www-form-urlencoded' -Body 'sink=xml&payload=AAAAAA=='
#   profiler hits carry  "source":"profiler"

Tear down with stop-profiler-detection.ps1 — detach by default (removes the per-pool env, recycles the pool, stops the controller; the CLSID stays registered for a fast re-start), or -Full to also unregister the CLSID:

powershell -ExecutionPolicy Bypass -File scripts\stop-profiler-detection.ps1          # detach
powershell -ExecutionPolicy Bypass -File scripts\stop-profiler-detection.ps1 -Full    # + unregister

Manual verbs (what the launcher wraps):

$exe = 'src\CVEonDeserializationFinder.AmsiController\bin\x64\Release\CVEonDeserializationFinder.AmsiController.exe'
& $exe install-profiler   --pool CVEDeserializationLab --recycle     # deploy + register + attach
& $exe uninstall-profiler --pool CVEDeserializationLab               # detach (CLSID left)
& $exe uninstall-profiler --pool CVEDeserializationLab --unregister  # detach + drop CLSID

install-profiler sets three per-pool environment variables via appcmd so the profiler loads in the pool's next w3wp:

Variable Value
COR_ENABLE_PROFILING 1
COR_PROFILER {D44ADB37-8AAD-4583-8023-129CCE9ABFA9} (profiler CLSID, ≠ AMSI provider)
COR_PROFILER_PATH %ProgramData%\CVEonDeserializationFinder\provider\...NativeProfiler.dll

The scripted end-to-end proof is scripts/atomic-profiler-test.ps1 — it builds, attaches, drives a dynamic serializer path in w3wp.exe, and asserts a source=profiler hit; it ends PASS.

Rule format

rules/gadgets.json is a JSON array validated against rules/gadgets.schema.json (draft-07). One entry per gadget shape:

{
  "id": "GADGET-ExpandedWrapper-LosFormatter-ObjectDataProvider",
  "gadget": "ExpandedWrapper<LosFormatter, ObjectDataProvider>",
  "severity": "critical",
  "description": "ToolShell / CVE-2025-53770 payload shape.",
  "source": "ysoserial.net/Generators/ObjectDataProviderGenerator.cs",
  "match": {
    "typeRefs": ["ExpandedWrapper", "LosFormatter", "ObjectDataProvider"],
    "assemblyRefsAny": [
      "System.Data.Services",
      "System.Web",
      "PresentationFramework",
    ],
    "typeDefsContainsAny": [],
  },
}

Matching semantics — all listed conditions must be satisfied:

  • typeRefs — every entry must appear in TypeRef ∪ TypeDef ∪ TypeSpec (name-only match; X matches X<...> via a suffix rule).
  • assemblyRefsAny — at least one entry must be present in AssemblyRef.
  • typeDefsContainsAny — at least one substring must appear in a TypeDef name (optional heuristic).

End-to-end atomic tests

Two one-pass scripts build, deploy, start the controller, fire an exploit, and verify the whole evidence trail in a single run — re-runnable verbatim each iteration:

  • scripts/atomic-test.ps1 — PowerShell host. Builds provider + controller, redeploys the DLL into ProgramData, starts one listener, spawns a fresh powershell.exe that Assembly.Loads a marker assembly referencing ObjectDataProvider + LosFormatter, then asserts provider.log grew, an inbound\ dump appeared, and a GADGET-ObjectDataProvider hit landed. The first elevated run also grants the interactive user Modify on the ProgramData tree so later runs need no elevation.
  • scripts/atomic-iis-test.ps1 — IIS / w3wp.exe host (elevated). Recycles the app pool on http://localhost:8088, primes the site, POSTs sink=marker to /api/deserialize.ashx, and verifies the same trail with proc=w3wp.exe.
  • scripts/atomic-profiler-test.ps1 — CLR profiler channel (elevated). Builds controller + profiler, attaches the profiler to the app pool, drives a dynamic XmlSerializer path in w3wp.exe, and asserts a source=profiler hit lands (five hits incl. GADGET-ObjectDataProvider). Ends PASS.
# PowerShell host (first run elevated to grant ACL, later runs plain):
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\atomic-test.ps1

# IIS / w3wp host (elevated — recycles the app pool):
Start-Process powershell -Verb RunAs -Wait -ArgumentList `
  '-NoProfile','-ExecutionPolicy','Bypass','-File','scripts\atomic-iis-test.ps1'

Both end in a PASS line when the provider → controller → hit chain is intact.

Validation cheat-sheet

Assembly.Load(byte[]) only reaches AMSI in hosts that initialised the AMSI subsystem. Windows PowerShell 5.1 qualifies out of the box:

$hits = 'C:\ProgramData\CVEonDeserializationFinder\hits.log'
Remove-Item $hits -ErrorAction SilentlyContinue

# WPF assembly contains ObjectDataProvider TypeDef → several heuristic rules fire.
& 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -NoProfile -Command @'
$b = [IO.File]::ReadAllBytes('C:\Windows\Microsoft.NET\Framework64\v4.0.30319\WPF\PresentationFramework.dll')
[Reflection.Assembly]::Load($b) | Out-Null
'@

Get-Content $hits

Full end-to-end (managed-PE gadget load → IIS/w3wp → AMSI hit + dump):

# The scripted path is scripts\atomic-iis-test.ps1 (recycles the pool, POSTs sink=marker,
# verifies the w3wp trail). To drive it by hand instead, with a listener already running:
Invoke-WebRequest http://localhost:8088/api/deserialize.ashx -Method POST -UseBasicParsing `
  -ContentType 'application/x-www-form-urlencoded' -Body 'sink=marker&payload=AAAAAA=='
#   Watch:
#     - provider.log gains  scan proc=w3wp.exe kind=managed  +  pipe req=... status=ok verdict=1
#     - inbound\ gets a fresh <ts>_<pid>_w3wp.exe_<reqid>.bin + .json
#     - hits.log gains a "level":"hit" with rule GADGET-ObjectDataProvider
& $exe tail-hits --follow                                      # elevated

Note: a real XmlSerializer ToolShell payload materialises its serializer as a dynamic assembly (Microsoft.GeneratedCode, no PE image), which the CLR AMSI hook does not scan. AMSI sees the gadget only when a managed PE is loaded via Assembly.Load(byte[]) — see Caveats.

Caveats

  • AMSI host requirement. Plain .NET Framework console apps do not initialise AMSI; the CLR does not scan Assembly.Load(byte[]) for them. Real targets (w3wp.exe, powershell.exe 5.1, Office VBA hosts) do. The Tester harness contains an explicit AmsiInitialize + AmsiScanBuffer wrapper to emulate this locally.
  • XmlSerializer helper is dynamic. The serializer assembly that XmlSerializer generates for the ToolShell type (List<ExpandedWrapper<LosFormatter, ObjectDataProvider>>) is emitted with Reflection.Emit as Microsoft.GeneratedCode — a dynamic assembly with no PE image — so the CLR AMSI hook never scans it. Detection fires on the managed PE a gadget chain ultimately loads via Assembly.Load(byte[]); the marker lab sink and atomic-*.ps1 scripts reproduce exactly that byte[] load.
  • Dynamic-assembly coverage (CLR profiler). The secondary channel closes the XmlSerializer blind spot with an ICorProfilerCallback3 profiler activated per-app-pool (COR_ENABLE_PROFILING / COR_PROFILER / COR_PROFILER_PATH). SPIKE-A pinned two design-driving facts: the dynamic module's gadget TypeRefs are empty at ModuleLoadFinished and only appear at ClassLoadFinished (module Microsoft.GeneratedCode), and a ICorProfilerCallback2-only profiler is rejected by the .NET Framework 4.x CLR (event 0x2517) — so the profiler must implement ICorProfilerCallback3. The gadget signal (ObjectDataProvider, LosFormatter, ExpandedWrapper) is carried in TypeRefs and is observable at serializer-construction time, before the payload is parsed. Wired end-to-end and driven by scripts\start-profiler-detection.ps1; design + phases in docs/profiler-integration-plan.md.
  • Unsigned DLL. On systems with strict AMSI-provider signing (some server SKUs, WDAC policies), the provider DLL must be Authenticode-signed to load. For lab / dev use, unsigned load succeeded on Windows 11 26100 alongside Kaspersky KES.
  • Heuristic false positives. Some heuristic rules match legitimate framework DLLs (e.g. XAML gadget rules trigger on PresentationFramework.dll). The precise ToolShell rule does not have this false positive — it requires the closed generic TypeRef set only produced by the CVE-2025-53770 payload shape.
  • Web-app is intentionally vulnerable. Do not deploy WebApplication anywhere reachable from an untrusted network. It is bound to http://localhost:8088 only; keep it that way.

Related documents

  • README_RU.md — Russian translation, structure-locked to this file.
  • AGENTS.md — automated-agent contract (build commands, mirror rule, layout rules).
  • docs/CFP-EN.md — call-for-papers reference for Code Blue / DefCamp / Black Alpaca / PumaHat / Black Hat EU / Black Hat MEA 2026.
  • docs/CFP-RU.md — Russian mirror.

About

Runtime detection of .NET deserialization attacks using AMSI and CLR profiling, with metadata-based gadget detection for dynamic assemblies.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages